/*******************************************************************************
 * Copyright (c) 2010, 2018 Tom Schindl and others.
 *
 * This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     Tom Schindl <tom.schindl@bestsolution.at> - initial API and implementation
 *     Brian de Alwis - added support for multiple CSS engines
 *     Lars Vogel <Lars.Vogel@gmail.com> - Bug 422702
 *     IBM Corporation - initial API and implementation
 *     Lucas Bullen (Red Hat Inc.) - [Bug 527806] Ship all themes for all OS & WS
 *******************************************************************************/
package org.eclipse.e4.ui.css.swt.internal.theme;

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.eclipse.core.runtime.Assert;
import org.eclipse.core.runtime.FileLocator;
import org.eclipse.core.runtime.IConfigurationElement;
import org.eclipse.core.runtime.IExtension;
import org.eclipse.core.runtime.IExtensionPoint;
import org.eclipse.core.runtime.IExtensionRegistry;
import org.eclipse.core.runtime.IPath;
import org.eclipse.core.runtime.Path;
import org.eclipse.core.runtime.Platform;
import org.eclipse.core.runtime.RegistryFactory;
import org.eclipse.core.runtime.preferences.IEclipsePreferences;
import org.eclipse.core.runtime.preferences.InstanceScope;
import org.eclipse.e4.ui.css.core.engine.CSSElementContext;
import org.eclipse.e4.ui.css.core.engine.CSSEngine;
import org.eclipse.e4.ui.css.core.util.impl.resources.FileResourcesLocatorImpl;
import org.eclipse.e4.ui.css.core.util.impl.resources.OSGiResourceLocator;
import org.eclipse.e4.ui.css.core.util.resources.IResourceLocator;
import org.eclipse.e4.ui.css.swt.helpers.EclipsePreferencesHelper;
import org.eclipse.e4.ui.css.swt.theme.ITheme;
import org.eclipse.e4.ui.css.swt.theme.IThemeEngine;
import org.eclipse.osgi.service.datalocation.Location;
import org.eclipse.swt.widgets.Display;
import org.osgi.framework.Bundle;
import org.osgi.framework.BundleContext;
import org.osgi.framework.FrameworkUtil;
import org.osgi.framework.ServiceReference;
import org.osgi.service.event.Event;
import org.osgi.service.event.EventAdmin;
import org.osgi.service.prefs.BackingStoreException;
import org.w3c.css.sac.InputSource;
import org.w3c.dom.Element;
import org.w3c.dom.css.CSSStyleDeclaration;

public class ThemeEngine implements IThemeEngine {
	private List<Theme> themes = new ArrayList<>();
	private List<CSSEngine> cssEngines = new ArrayList<>();

	// kept for theme notifications only
	private Display display;

	private ITheme currentTheme;

	private List<String> globalStyles = new ArrayList<>();
	private List<IResourceLocator> globalSourceLocators = new ArrayList<>();

	private HashMap<String, List<String>> stylesheets = new HashMap<>();
	private HashMap<String, List<String>> modifiedStylesheets = new HashMap<>();
	private HashMap<String, List<IResourceLocator>> sourceLocators = new HashMap<>();

	private static final String THEMEID_KEY = "themeid";

	public static final String THEME_PLUGIN_ID = "org.eclipse.e4.ui.css.swt.theme";

	public static final String E4_DARK_THEME_ID = "org.eclipse.e4.ui.css.theme.e4_dark";

	public ThemeEngine(Display display) {
		this.display = display;

		IExtensionRegistry registry = RegistryFactory.getRegistry();
		IExtensionPoint extPoint = registry.getExtensionPoint(THEME_PLUGIN_ID);

		//load any modified style sheets
		Location configLocation = org.eclipse.core.runtime.Platform.getConfigurationLocation();
		String e4CSSPath = null;
		try {
			URL locationURL = new URL(configLocation.getDataArea(ThemeEngine.THEME_PLUGIN_ID).toString());
			File locationFile = new File(locationURL.getFile());
			e4CSSPath = locationFile.getPath();
		} catch (IOException e1) {
		}

		IPath path = new Path(e4CSSPath + File.separator);
		File modDir= new File(path.toFile().toURI());
		if (!modDir.exists()) {
			modDir.mkdirs();
		}

		File[] modifiedFiles = modDir.listFiles();
		String currentOS = Platform.getOS();
		boolean e4_dark_mac_found = false;

		for (IExtension e : extPoint.getExtensions()) {
			for (IConfigurationElement ce : getPlatformMatches(e.getConfigurationElements())) {
					if (ce.getName().equals("theme")) {
						try {
							String id = ce.getAttribute("id");
							String os = ce.getAttribute("os");
							String version = ce.getAttribute("os_version");

							/*
							 * Code to support e4 dark theme on Mac 10.13 and older. For e4 dark theme on
							 * Mac, register the theme with matching OS version if specified.
							 */
							if (E4_DARK_THEME_ID.equals(id) && Platform.OS_MACOSX.equals(currentOS) && os != null
									&& os.equals(currentOS)) {
								// If e4 dark theme on Mac was already registered, don't try to match or
								// register again.
								if (e4_dark_mac_found) {
									continue;
								}
								if (version != null && !isOsVersionMatch(version)) {
									continue;
								} else {
									e4_dark_mac_found = true;
									version = "";
								}
							}

							if (version == null) {
								version = "";
							}

							final String themeBaseId = id + version;
							String themeId = themeBaseId;
							String label = ce.getAttribute("label");
							String originalCSSFile;
							String basestylesheeturi = originalCSSFile = ce.getAttribute("basestylesheeturi");
							if (!basestylesheeturi.startsWith("platform:/plugin/")) {
								basestylesheeturi = "platform:/plugin/" + ce.getContributor().getName() + "/"
										+ basestylesheeturi;
							}
							registerTheme(themeId, label, basestylesheeturi, version);

							//check for modified files
							if (modifiedFiles != null) {
								int slash = originalCSSFile.lastIndexOf('/');
								if (slash != -1) {
									originalCSSFile = originalCSSFile.substring(slash + 1);
									for (File modifiedFile : modifiedFiles) {
										String modifiedFileName = modifiedFile.getName();
										if (modifiedFileName.contains(".css") && modifiedFileName.equals(originalCSSFile)) {  //$NON-NLS-1$
											//								modifiedStylesheets
											ArrayList<String> styleSheets = new ArrayList<>();
											styleSheets.add(modifiedFile.toURI().toString());
											modifiedStylesheets.put(themeId, styleSheets);
										}
									}
								}
							}
						} catch (IllegalArgumentException e1) {
							ThemeEngineManager.logError(e1.getMessage(), e1);
						}
					}
				}
			}

			for (IExtension e : extPoint.getExtensions()) {
				for (IConfigurationElement ce : getPlatformMatches(e.getConfigurationElements())) {
					if (ce.getName().equals("stylesheet")) {
						IConfigurationElement[] cces = ce.getChildren("themeid");
						if (cces.length == 0) {
							registerStylesheet("platform:/plugin/"
									+ ce.getContributor().getName() + "/"
									+ ce.getAttribute("uri"));

							for (IConfigurationElement resourceEl : ce
									.getChildren("osgiresourcelocator")) {
								String uri = resourceEl.getAttribute("uri");
								if (uri != null) {
									registerResourceLocator(new OSGiResourceLocator(
											uri));
								}
							}
						} else {
							String[] themes = new String[cces.length];
							for (int i = 0; i < cces.length; i++) {
								themes[i] = cces[i].getAttribute("refid");
							}
							registerStylesheet(
									"platform:/plugin/" + ce.getContributor().getName() + "/" + ce.getAttribute("uri"),
									themes);

							for (IConfigurationElement resourceEl : ce
									.getChildren("osgiresourcelocator")) {
								String uri = resourceEl.getAttribute("uri");
								if (uri != null) {
									registerResourceLocator(new OSGiResourceLocator(
											uri));
								}
							}
						}
					}
				}
			}

			// register a default resolver for platform uri's
			registerResourceLocator(new OSGiResourceLocator(
					"platform:/plugin/org.eclipse.ui.themes/css/"));
			// register a default resolver for file uri's
			registerResourceLocator(new FileResourcesLocatorImpl());
			// FIXME: perhaps ResourcesLocatorManager shouldn't have a default?
			// registerResourceLocator(new HttpResourcesLocatorImpl());
		}


		private boolean isOsVersionMatch(String osVersionList) {
			boolean found = false;
			String osVersion = System.getProperty("os.version");
			if (osVersion != null) {
				if (osVersionList != null) {
					String[] osVersions = osVersionList.split(","); //$NON-NLS-1$
					for (String osVersionFromTheme : osVersions) {
						if (osVersionFromTheme != null) {
							if (osVersion.contains(osVersionFromTheme)) {
								found = true;
								break;
							}
						}
					}
				}
			}
			return found;
		}

		@Override
		public synchronized ITheme registerTheme(String id, String label, String basestylesheetURI)
				throws IllegalArgumentException {
			return  registerTheme(id, label, basestylesheetURI, "");
		}

		public synchronized ITheme registerTheme(String id, String label,
				String basestylesheetURI, String osVersion) throws IllegalArgumentException {
			for (Theme t : themes) {
				if (t.getId().equals(id)) {
					throw new IllegalArgumentException("A theme with the id '" + id
							+ "' is already registered");
				}
			}
			Theme theme = new Theme(id, label);
			if (osVersion != "") {
				theme.setOsVersion(osVersion);
			}
			themes.add(theme);
			registerStyle(id, basestylesheetURI);
			return theme;
		}

		@Override
		public synchronized void registerStylesheet(String uri, String... themes) {
			Bundle bundle = FrameworkUtil.getBundle(ThemeEngine.class);
			String osname = bundle.getBundleContext().getProperty("osgi.os");
			String wsname = bundle.getBundleContext().getProperty("osgi.ws");

			uri = uri.replaceAll("\\$os\\$", osname).replaceAll("\\$ws\\$", wsname);

			if (themes.length == 0) {
				globalStyles.add(uri);
			} else {
				for (String t : themes) {
					registerStyle(t, uri);
				}
			}
		}

		@Override
		public synchronized void registerResourceLocator(IResourceLocator locator,
				String... themes) {
			if (themes.length == 0) {
				globalSourceLocators.add(locator);
			} else {
				for (String t : themes) {
					List<IResourceLocator> list = sourceLocators.get(t);
					if (list == null) {
						list = new ArrayList<>();
						sourceLocators.put(t, list);
					}
					list.add(locator);
				}
			}
		}

		private void registerStyle(String id, String stylesheet) {
			List<String> s = stylesheets.get(id);
			if (s == null) {
				s = new ArrayList<>();
				stylesheets.put(id, s);
			}
			s.add(stylesheet);
		}

		private List<String> getAllStyles(String id) {
			// check for any modifications first
			List<String> m = modifiedStylesheets.get(id);
			if (m != null) {
				m = new ArrayList<>(m);
				m.addAll(globalStyles);
				return m;
			}

			List<String> s = stylesheets.get(id);
			if (s == null) {
				s = Collections.emptyList();
			}

			s = new ArrayList<>(s);
			s.addAll(globalStyles);
			return s;

		}

		private List<IResourceLocator> getResourceLocators(String id) {
			List<IResourceLocator> list = new ArrayList<>(
					globalSourceLocators);
			List<IResourceLocator> s = sourceLocators.get(id);
			if (s != null) {
				list.addAll(s);
			}

			return list;
		}

		/**
		 * Get all elements that have os/ws attributes that best match the current
		 * platform.
		 *
		 * @param elements the elements to check
		 * @return the best matches, if any
		 */
		private IConfigurationElement[] getPlatformMatches(IConfigurationElement[] elements) {
			Bundle bundle = FrameworkUtil.getBundle(ThemeEngine.class);
			String osname = bundle.getBundleContext().getProperty("osgi.os");
			// TODO: Need to differentiate win32 versions
			String wsname = bundle.getBundleContext().getProperty("osgi.ws");
			ArrayList<IConfigurationElement> matchingElements = new ArrayList<>();
			for (IConfigurationElement element : elements) {
				String elementOs = element.getAttribute("os");
				String elementWs = element.getAttribute("ws");
				if (osname != null && (elementOs == null || elementOs.contains(osname))) {
					matchingElements.add(element);
				} else if (wsname != null && wsname.equalsIgnoreCase(elementWs)) {
					matchingElements.add(element);
				}
			}
			return matchingElements.toArray(new IConfigurationElement[matchingElements.size()]);
		}

		@Override
		public void setTheme(String themeId, boolean restore) {
			String osVersion = System.getProperty("os.version");
			if (osVersion != null) {
				boolean found = false;
				for (Theme t : themes) {
					String osVersionList = t.getOsVersion();
					if (osVersionList != null) {
						String[] osVersions = osVersionList.split(","); //$NON-NLS-1$
						for (String osVersionFromTheme : osVersions) {
							if (osVersionFromTheme != null && osVersion.contains(osVersionFromTheme)) {
								String themeVersion = themeId + osVersionList;
								if (t.getId().equals(themeVersion)) {
									setTheme(t, restore);
									found = true;
									break;
								}
							}
						}
					}
				}
				if (found) {
					return;
				}
			}
			//try generic
			for (Theme t : themes) {
				if (t.getId().equals(themeId)) {
					setTheme(t, restore);
					break;
				}
			}
		}

		@Override
		public void setTheme(ITheme theme, boolean restore) {
			setTheme(theme, restore, false);
		}

		public void setTheme(ITheme theme, boolean restore, boolean force) {
			Assert.isNotNull(theme, "The theme must not be null");

			if (this.currentTheme != theme || force) {
				if (currentTheme != null) {
					for (IResourceLocator l : getResourceLocators(currentTheme
							.getId())) {
						for (CSSEngine engine : cssEngines) {
							engine.getResourcesLocatorManager()
							.unregisterResourceLocator(l);
						}
					}
				}

				this.currentTheme = theme;
				for (CSSEngine engine : cssEngines) {
					engine.reset();
				}

				for (IResourceLocator l : getResourceLocators(theme.getId())) {
					for (CSSEngine engine : cssEngines) {
						engine.getResourcesLocatorManager()
						.registerResourceLocator(l);
					}
				}
				for (String stylesheet : getAllStyles(theme.getId())) {
					URL url;
					InputStream stream = null;
					try {
						url = FileLocator.resolve(new URL(stylesheet));
						for (CSSEngine engine : cssEngines) {
							try {
								stream = url.openStream();
								InputSource source = new InputSource();
								source.setByteStream(stream);
								source.setURI(url.toString());
								engine.parseStyleSheet(source);
							} catch (IOException e) {
								ThemeEngineManager.logError(e.getMessage(), e);
							} finally {
								if (stream != null) {
									try {
										stream.close();
									} catch (IOException e) {
										ThemeEngineManager.logError(e.getMessage(), e);
									}
								}
							}
						}
					} catch (IOException e) {
						ThemeEngineManager.logError(e.getMessage(), e);
					}
				}
			}

			if (restore) {
				IEclipsePreferences pref = getPreferences();
				EclipsePreferencesHelper.setPreviousThemeId(pref.get(THEMEID_KEY, null));
				EclipsePreferencesHelper.setCurrentThemeId(theme.getId());

				pref.put(THEMEID_KEY, theme.getId());
				try {
					pref.flush();
				} catch (BackingStoreException e) {
					ThemeEngineManager.logError(e.getMessage(), e);
				}
			}
			sendThemeChangeEvent(restore);

			for (CSSEngine engine : cssEngines) {
				engine.reapply();
			}
		}

		/**
		 * Broadcast theme-change event using OSGi Event Admin.
		 */
		private void sendThemeChangeEvent(boolean restore) {
			EventAdmin eventAdmin = getEventAdmin();
			if (eventAdmin == null) {
				return;
			}
			Map<String, Object> data = new HashMap<>();
			data.put(IThemeEngine.Events.THEME_ENGINE, this);
			data.put(IThemeEngine.Events.THEME, currentTheme);
			data.put(IThemeEngine.Events.DEVICE, display);
			data.put(IThemeEngine.Events.RESTORE, restore);
			Event event = new Event(IThemeEngine.Events.THEME_CHANGED, data);
			eventAdmin.sendEvent(event); // synchronous
		}

		public List<IResourceLocator> getCurrentResourceLocators() {
			if(currentTheme == null) { return Collections.emptyList(); }
			return getResourceLocators(currentTheme.getId());
		}

		private EventAdmin getEventAdmin() {
			Bundle bundle = FrameworkUtil.getBundle(this.getClass());
			if (bundle == null) {
				return null;
			}
			BundleContext context = bundle.getBundleContext();
			ServiceReference<EventAdmin> eventAdminRef = context.getServiceReference(EventAdmin.class);
			return context.getService(eventAdminRef);
		}

		@Override
		public synchronized List<ITheme> getThemes() {
			return Collections.unmodifiableList(new ArrayList<ITheme>(themes));
		}

		@Override
		public void applyStyles(Object widget, boolean applyStylesToChildNodes) {
			for (CSSEngine engine : cssEngines) {
				Element element = engine.getElement(widget);
				if (element != null) {
					engine.applyStyles(element, applyStylesToChildNodes);
				}
			}
		}

		private String getPreferenceThemeId() {
			return getPreferences().get(THEMEID_KEY, null);
		}

		private IEclipsePreferences getPreferences() {
			return InstanceScope.INSTANCE.getNode(FrameworkUtil.getBundle(ThemeEngine.class).getSymbolicName());
		}

		void copyFile(String from, String to) throws IOException {
			FileInputStream fStream = null;
			BufferedOutputStream outputStream = null;
			try {
				fStream = new FileInputStream(from);
				outputStream = new BufferedOutputStream(new FileOutputStream(to));
				byte[] buffer = new byte[4096];
				int c;
				while ((c = fStream.read(buffer)) != -1) {
					outputStream.write(buffer, 0, c);
				}

			} finally {
				if (fStream != null) {
					fStream.close();
				}
				if (outputStream != null) {
					outputStream.close();
				}
			}
		}

		@Override
		public void restore(String alternateTheme) {
			String prefThemeId = getPreferenceThemeId();

			// Bug 562794, 563601: Eclipse once contained two identical themes named
			// "Classic" and "Windows Classic" and the second was removed with bug 562794.
			// An old workspace using the removed "Windows Classic" theme would be reseted
			// to the default theme on update. Since both themes are identical we silently
			// change the theme to the remaining "Classic" theme and don't disturb the user.
			if ("org.eclipse.e4.ui.css.theme.e4_classic6.0,6.1,6.2,6.3".equals(prefThemeId)) { //$NON-NLS-1$
				prefThemeId = "org.eclipse.e4.ui.css.theme.e4_classic"; //$NON-NLS-1$
			}

			boolean flag = true;
			if (prefThemeId != null) {
				for (ITheme t : getThemes()) {
					if (prefThemeId.equals(t.getId())) {
						setTheme(t, false);
						flag = false;
						break;
					}
				}
			}

			// For Mac & GTK, if the system has Dark appearance set and Eclipse is using the
			// default settings, then start Eclipse in Dark theme.

			// Check that there is a dark theme present
			boolean hasDarkTheme = getThemes().stream().anyMatch(t -> t.getId().startsWith(E4_DARK_THEME_ID));

			String os = Platform.getOS();
			String themeToRestore = (Platform.OS_LINUX.equals(os) || Platform.OS_MACOSX.equals(os))
					&& Display.isSystemDarkTheme() && hasDarkTheme ? E4_DARK_THEME_ID : alternateTheme;
			if (themeToRestore != null && flag) {
				setTheme(themeToRestore, false);
			}
		}

		@Override
		public ITheme getActiveTheme() {
			return currentTheme;
		}

		@Override
		public CSSStyleDeclaration getStyle(Object widget) {
			for (CSSEngine engine : cssEngines) {
				CSSElementContext context = engine.getCSSElementContext(widget);
				if (context != null) {
					Element e = context.getElement();
					if (e != null) {
						return engine.getViewCSS().getComputedStyle(e, null);
					}
				}
			}
			return null;
		}

		public List<String> getStylesheets(ITheme selection) {
			List<String> ss  = stylesheets.get(selection.getId());
			return ss == null ? new ArrayList<>() : ss;
		}

		public void themeModified(ITheme theme, List<String> paths) {
			modifiedStylesheets.put(theme.getId(), paths);
			setTheme(theme, false, true);
		}

		public void resetCurrentTheme() {
			if (currentTheme != null) {
				setTheme(currentTheme, false, true);
			}
		}

		public List<String> getModifiedStylesheets(ITheme selection) {
			List<String> ss  = modifiedStylesheets.get(selection.getId());
			return ss == null ? new ArrayList<>() : ss;
		}

		public void resetModifiedStylesheets(ITheme selection) {
			modifiedStylesheets.remove(selection.getId());
		}

		@Override
		public void addCSSEngine(CSSEngine cssEngine) {
			cssEngines.add(cssEngine);
			resetCurrentTheme();
		}

		public Collection<CSSEngine> getCSSEngines() {
			return cssEngines;
		}

		@Override
		public void removeCSSEngine(CSSEngine cssEngine) {
			cssEngines.remove(cssEngine);
		}
	}
